Skip to content

fix: break CurlHandle reference cycle leaking connections - #22

Closed
loks0n wants to merge 1 commit into
mainfrom
fix/curl-closure-cycle-leak
Closed

fix: break CurlHandle reference cycle leaking connections#22
loks0n wants to merge 1 commit into
mainfrom
fix/curl-closure-cycle-leak

Conversation

@loks0n

@loks0n loks0n commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

Curl::send() registers the header and write callbacks as non-static closures. Closures declared in an instance method bind $this, and curl_setopt stores them on the CurlHandle — which the adapter itself holds:

adapter → CurlHandle → closure → adapter

This reference cycle keeps the handle alive after the last external reference to the Client is dropped, so __destruct (and its curl_close) only runs when PHP's cycle collector fires — by default after 10,000 cycle roots accumulate. Until then, every request from a fresh Client leaks an open keep-alive connection: 2 fds and ~1MB of native TLS buffers per request, invisible to memory_get_usage().

In long-running processes (Swoole workers) doing per-request new Client(), this is catastrophic: we traced an hourly OOMKill of Appwrite Cloud's billing aggregation worker to exactly this — a single worker child held 400+ ESTABLISHED TLS connections and >100MB of native memory mid-job.

Measurements (150 sequential requests, one process)

fds private-dirty RSS
before 309 (grows +2/req) 183MB (grows ~1MB/req)
after (static closures) 9 (flat) 31MB (flat)

Fix

Mark both callbacks static — neither uses $this, so this is behavior-preserving and simply prevents the binding that forms the cycle.

🤖 Generated with Claude Code

The header and write callbacks are non-static closures, so they bind
$this. curl_setopt stores them on the CurlHandle, which the adapter
already holds: adapter -> handle -> closure -> adapter. The cycle keeps
the handle alive past refcount zero, so __destruct never runs until the
cycle collector fires (10k roots) and every request leaks an open
keep-alive connection (~1MB native TLS buffers, 2 fds).

Measured in a long-running Swoole worker doing sequential requests:
150 requests = 309 open fds and 183MB private-dirty RSS; with static
closures: 9 fds and flat memory. Neither callback uses $this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Greptile Summary

Prevents retained cURL connections by making the header and response-body callbacks static, removing their implicit reference to the adapter instance.

Confidence Score: 5/5

The PR appears safe to merge with no actionable defects identified.

The modified callbacks use only parameters and explicitly captured local variables, and the repository's supported PHP versions accept static closures.

Important Files Changed

Filename Overview
src/Adapter/Curl.php Both cURL callbacks are now static; neither accesses instance state, so callback behavior remains unchanged while the CurlHandle reference cycle is removed.

Reviews (1): Last reviewed commit: "fix: break CurlHandle reference cycle le..." | Re-trigger Greptile

@loks0n loks0n closed this Jul 27, 2026
loks0n added a commit to utopia-php/pay that referenced this pull request Aug 4, 2026
Adapter::call() built a new Utopia\Fetch\Client for every request. Each
one leaks its cURL handle and the connection behind it through a closure
reference cycle (utopia-php/fetch#22), so a long-running process grows
without bound in native memory that PHP's memory_limit cannot see.

In production this OOM-killed task-billing-payments: the pod idles at
~330MB of its 512Mi limit, and the daily due-invoice batch (~1000
invoices, several Stripe calls each) added ~200MB in four minutes and
crossed the limit. The PHP heap stayed flat throughout, so nothing was
logged before the kernel killed it.

Swap to utopia-php/client, which keeps one cURL handle for the lifetime
of the adapter and reuses its connection. Measured over 400 identical
requests:

    before   +800 fds, +21 MB RSS
    after      +0 fds, +64 kB RSS

The client is injected through the constructor and readonly, so the
transport is the caller's to choose: a pool for coroutine contexts, a
retry decorator, its own timeouts, or a double under test. The adapter
holds one request factory for the same reason it holds one client.

Two consequences of the swap:

- utopia-php/client requires PHP 8.5, so pay does too. The CI matrix
  drops 8.0-8.3.
- The multipart/form-data branch and its flatten() helper are removed.
  No adapter used them, and the new request factory models multipart as
  typed parts rather than a flattened array, so a future adapter should
  build them through that instead of resurrecting the old shape.

GET params move from the request body to the query string, which is
where they belong. Stripe accepts either — verified against the live API
that a created[gt] filter is honoured both ways — so this changes the
wire format, not behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loks0n added a commit to utopia-php/pay that referenced this pull request Aug 4, 2026
Adapter::call() built a new Utopia\Fetch\Client for every request. Each
one leaks its cURL handle and the connection behind it through a closure
reference cycle (utopia-php/fetch#22), so a long-running process grows
without bound in native memory that PHP's memory_limit cannot see.

In production this OOM-killed task-billing-payments: the pod idles at
~330MB of its 512Mi limit, and the daily due-invoice batch (~1000
invoices, several Stripe calls each) added ~200MB in four minutes and
crossed the limit. The PHP heap stayed flat throughout, so nothing was
logged before the kernel killed it.

Stripe now builds its requests with utopia-php/psr7's factory and sends
them with a utopia-php/client that keeps one cURL handle for the
lifetime of the adapter. Measured over 400 identical requests:

    before   +800 fds, +21 MB RSS
    after      +0 fds, +48 kB RSS

The client is injected through the constructor and readonly, so the
transport is the caller's to choose: a pool for coroutine contexts, a
retry decorator, its own timeouts, or a double under test.

Adapter::call() and the METHOD_* constants are gone with it. A PSR-18
client and a PSR-17 factory already express "build a request, send it",
so the indirection bought nothing but a second vocabulary for HTTP —
seven of the nine constants had no caller. Adapter goes back to being
the provider contract, with no imports and no transport of its own;
Stripe owns its client because the wire format is its concern, not the
contract's.

Two consequences of the swap:

- utopia-php/client requires PHP 8.5, so pay does too. The CI matrix
  drops 8.0-8.3.
- The multipart/form-data branch and its flatten() helper are removed.
  No adapter used them, and the new request factory models multipart as
  typed parts rather than a flattened array, so a future adapter should
  build them through that instead of resurrecting the old shape.

GET params move from the request body to the query string, which is
where they belong. Stripe accepts either — verified against the live API
that a created[gt] filter is honoured both ways — so this changes the
wire format, not behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant